API Design Patterns — 16장 다형성 딥다이브 학습 노트
출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/
16장에서 다루는 내용
- 다형성이란 — OOP의 다형성을 API에 적용
- 다형성의 장점과 API에서의 용례 — 중복 메서드 방지, 여러 타입 함께 나열
- 다형성 리소스를 구성하는 방법 — type 필드, 데이터 구조, 데이터 검증
- 리소스 지향 API에서 다형성 메서드를 피해야 하는 이유 — 동작 분화 위험
전체 흐름도
[OOP 다형성] [API 다형성] [구현 세부사항]
| | |
v v v
+------------------+ +---------------------+ +---------------------------+
| interface Shape | | 다형성 리소스 | | 16.3.2 다형성 구조 |
| Triangle, Square |---->| = 하나의 제네릭 |---->| - type 필드 (문자열) |
| 가 구현 | | 리소스로 여러 타입 | | - 데이터 구조 (슈퍼셋) |
+------------------+ | 을 표현 | | - 데이터 검증 |
+---------------------+ +---------------------------+
| |
v +---------------------------+
+-----------+ | 16.3.3 다형성 동작 |
| ListShape | | 기존 메서드와 동일하게 동작 |
| 하나로 | +---------------------------+
| 모든 타입 | |
| 나열 가능 | +---------------------------+
+-----------+ | 16.3.4 다형성 메서드 |
| → 피해야 함! |
| DeleteResource() 위험 |
+---------------------------+
선수 지식 체크리스트
- [ ] REST API 기본 개념 (리소스, 엔드포인트, HTTP 메서드)
- [ ] 표준 메서드 (Get, Create, Update, Delete, List)
- [ ] OOP 기초 (인터페이스, 상속, 다형성 개념)
핵심 키워드
| 키워드 | 의미 |
|---|---|
| 다형성 | 하나의 공통 인터페이스를 여러 타입에 적용하는 개념 |
| 다형성 리소스 | type 필드로 세부 타입을 구분하는 제네릭 리소스 |
| 다형성 메서드 | 여러 리소스 타입에서 동작하는 제네릭 메서드 (비권장) |
| type 필드 | 리소스의 세부 타입을 명시하는 문자열 필드 |
| 슈퍼셋 | 모든 서브타입의 필드를 하나의 리소스에 모아놓은 구조 |
| 동작 분화 | 처음엔 같았던 동작이 시간이 지나며 타입별로 달라지는 현상 |
16.1 서론 — OOP의 다형성을 API에
한 줄 요약
객체지향 프로그래밍의 강력한 다형성을 리소스 지향 API에 접목한다!
쉬운 설명 (메시지 앱 비유)
카카오톡에서 보내는 메시지를 생각해보자: - 텍스트 메시지: "안녕하세요" (글자) - 사진 메시지: 사진 파일 - 동영상 메시지: 동영상 파일 - 음성 메시지: 녹음 파일
이것들은 모두 "메시지"라는 공통점이 있지만, 담는 내용이 다르다.
# 다형성 없이 — 메서드가 타입마다 필요
ListTextMessages() → 텍스트만
ListPhotoMessages() → 사진만
ListVideoMessages() → 동영상만
ListAudioMessages() → 음성만
# 채팅방 전체 메시지를 보려면 4번 호출! 😱
# 다형성 사용 — 하나의 메서드로 모든 타입
ListMessages() → 모든 타입의 메시지 한 번에! ✅
실무 예제 — 다형성이 필요한 실제 시스템들
1. 메시징 앱 (카카오톡, Slack)
Message: text, image, video, audio, file, location, sticker
→ ListMessages로 채팅 내역을 시간순 통합 나열
2. 결제 시스템
PaymentMethod: credit_card, bank_transfer, mobile_pay, crypto
→ ListPaymentMethods로 사용자의 모든 결제 수단 나열
3. 알림 시스템
Notification: email, push, sms, in_app
→ ListNotifications로 모든 타입의 알림을 통합 나열
4. CI/CD 파이프라인
Step: build, test, deploy, approval, notification
→ ListSteps로 파이프라인의 모든 단계를 순서대로 나열
5. CMS (콘텐츠 관리)
Block: text, image, video, code, quote, table
→ ListBlocks로 페이지의 모든 콘텐츠 블록을 순서대로 나열
TypeScript 다형성 예시
// 공통 인터페이스
interface Shape {
countSides(): number;
}
// 구현체 1
class Triangle implements Shape {
countSides() { return 3; }
}
// 구현체 2
class Square implements Shape {
countSides() { return 4; }
}
// 다형성 활용 — Triangle인지 Square인지 신경 안 써도 됨!
function countSides(shape: Shape): number {
return shape.countSides();
}
16.2 개괄 — 다형성 리소스의 개념
한 줄 요약
다형성 리소스는 제네릭 인터페이스(Message)의 형태를 취하며, type 필드로 세부 타입(text/photo/audio/video)을 특정한다.
장점
- 중복 방지: ListTextMessages, ListPhotoMessages 대신 ListMessages 하나로 충분
- 확장성: 새로운 타입 추가 시 메서드를 추가할 필요 없음
- 통합 목록: 채팅방의 모든 메시지를 한 번에 시간순으로 나열 가능
핵심 체크포인트
- ✅ 여러 타입을 함께 나열해야 할 때 다형성 리소스 사용
- ✅ type 필드로 서브타입을 구분하고 서버에서 검증
- ✅ 모든 표준 메서드가 동일하게 적용됨
16.3 구현
16.3.1 다형성 리소스 사용 여부 결정
의사결정 트리
Q1: 여러 타입의 리소스를 함께 나열해야 하는가?
├── NO → 독립 리소스 사용 (다형성 불필요)
│
└── YES → Q2: 각 타입이 "제네릭 리소스의 특수한 타입"인가?
├── NO → 독립 리소스 사용
│ (예: ChatRoom과 Broadcast는 근본적으로 다름)
│
└── YES → Q3: 타입 간 동작(메서드)이 동일한가?
├── YES → 다형성 리소스 사용 ✅
└── 약간 다름 → 다형성 리소스 사용 가능
(type 기반 분기로 처리)
사용해야 하는 경우 — 상세
1. 채팅 앱
텍스트, 사진, 동영상, 음성 메시지를 시간순으로 함께 나열
→ PhotoMessage는 Message의 특수한 타입인가? YES ✅
→ ListMessages 하나로 모든 타입 나열
2. 결제 수단
신용카드, 계좌이체, 모바일페이를 사용자 결제 수단으로 나열
→ CreditCard는 PaymentMethod의 특수한 타입인가? YES ✅
→ ListPaymentMethods 하나로 모든 결제 수단 나열
3. CMS 블록
텍스트, 이미지, 코드, 인용구를 페이지 순서대로 나열
→ ImageBlock은 Block의 특수한 타입인가? YES ✅
→ ListBlocks 하나로 모든 콘텐츠 블록 나열
사용하지 않는 것이 나은 경우 — 상세
1. 채팅방 vs 공지 그룹
ChatRoom: 양방향, 모든 멤버가 메시지 가능
Broadcast: 단방향, 관리자만 발신, 구독/해지 모델
→ Broadcast는 ChatRoom의 특수한 타입인가? NO ❌
→ 동작이 근본적으로 다름 → 별도 리소스
2. 파일 vs 폴더
File: 데이터를 저장, 다운로드 가능
Folder: 다른 항목을 포함하는 컨테이너
→ Folder는 File의 특수한 타입인가? NO ❌
→ 역할이 근본적으로 다름 → 별도 리소스
3. 사용자 vs 서비스 계정
User: 로그인, 프로필 수정, 비밀번호 변경
ServiceAccount: API 키 기반, 봇 동작, 비밀번호 없음
→ 인증/권한 모델이 완전히 다름 → 별도 리소스
16.3.2 다형성 구조
type 필드 정의
interface Message {
id: string;
sender: string;
type: 'text' | 'photo' | 'audio' | 'video'; // ← 문자열 타입 필드
}
왜 문자열인가? 열거형(enum)은 추가/삭제 시 기존 클라이언트 코드를 깨뜨릴 수 있다. 문자열이면 새 타입을 추가해도 기존 코드에 영향이 없다.
type은 불변: 한번 생성된 리소스의 타입을 바꾸면 다른 리소스가 이 리소스를 참조하는 전제가 깨진다. 비디오 메시지를 텍스트로 바꾸면 혼란 발생!
데이터 구조 — 3가지 방식
// 방식 1: 슈퍼셋 — 모든 타입의 필드를 포함
interface Message {
id: string;
sender: string;
type: 'text' | 'photo' | 'audio' | 'video';
text?: string; // text 타입일 때만 사용
photoUri?: string; // photo 타입일 때만 사용
videoUri?: string; // video 타입일 때만 사용
audioUri?: string; // audio 타입일 때만 사용
}
// 방식 2: 단일 content 필드 — 타입에 따라 다른 의미
interface Message {
id: string;
sender: string;
type: 'text' | 'photo' | 'audio' | 'video';
content: string; // text일 때는 텍스트, photo일 때는 URI
}
// 방식 3: content 필드 + Media 인터페이스 (권장)
interface Message {
id: string;
sender: string;
type: 'text' | 'image' | 'audio' | 'video';
content: string | Media; // text → string, 나머지 → Media
}
interface Media {
contentUri: string;
contentType: string; // "video/mp4" 등
}
도형 예제 — 차원 데이터가 타입별로 다른 경우
interface Shape {
id: string;
type: 'square' | 'circle' | 'triangle';
dimension: SquareDimension | CircleDimension | TriangleDimension;
}
interface SquareDimension { length: number; }
interface CircleDimension { radius: number; }
interface TriangleDimension { base: number; height: number; }
데이터 검증
# 정사각형인데 반지름 값을 보내면?
CreateShape({
type: 'square',
dimension: { radius: 10, length: 10 }
})
→ radius는 무시! (에러 반환보다 필요한 것만 확인 후 나머지 배제)
# 사진 메시지인데 content가 유효한 URI가 아니면?
CreateMessage({ type: 'photo', content: '그냥 텍스트' })
→ 에러 반환 (photo 타입이면 content는 유효한 URI여야 함)
원칙: 유효하지 않은 입력 데이터는 에러를 반환하는 대신, 필요한 데이터가 존재하는지 확인한 후 불필요한 데이터는 무시한다.
16.3.3 다형성 동작
// API 메서드 — 기존 표준/사용자 메서드와 동일하게 동작
abstract class ShapeApi {
@post("/{id=shapes/*}:countSides")
CountShapeSides(req: CountShapeSidesRequest): CountShapeSidesResponse;
}
// 호출 결과 — 타입에 따라 다른 결과
CountShapeSides({ id: "shapes/square-1" }) → { sides: 4 }
CountShapeSides({ id: "shapes/triangle-1" }) → { sides: 3 }
16.3.4 왜 다형성 메서드를 사용하지 않는가?
다형성 메서드의 유혹
// 모든 리소스 타입에서 동작하는 제네릭 삭제
abstract class GenericApi {
@delete("/{id=*/*}") // ← 모든 리소스 타입에 매핑
DeleteResource(req: DeleteResourceRequest): void;
}
"이게 훨씬 간단하잖아! 왜 안 쓰지?"
위험한 이유 2가지
1. 리소스 타입별로 동작이 달라질 수 있음
DeleteChatRoom() → 논리 삭제 (25장) + 검증 요청 (27장) 수행
DeleteMessage() → 즉시 물리 삭제
# 하나의 DeleteResource()로 합치면?
# 동작을 변경하면 모든 리소스에 영향!
2. 동작 변경이 모든 리소스에 전파됨
# ChatRoom 삭제에 검증을 추가하고 싶은데...
DeleteResource()를 수정하면 Message 삭제에도 검증이 적용됨!
→ 의도하지 않은 부작용
원칙: 다형성 리소스에 집중하고, 다형성 메서드는 피하라.
16.3.5 최종 API 정의
abstract class ChatRoomApi {
static version = "v1";
static title = "Chat Room API";
// === ChatRoom 표준 메서드 ===
@post("/chatRooms")
CreateChatRoom(req: CreateChatRoomRequest): ChatRoom;
@get("/{id=chatRooms/*}")
GetChatRoom(req: GetChatRoomRequest): ChatRoom;
@get("/chatRooms")
ListChatRooms(req: ListChatRoomsRequest): ListChatRoomsResponse;
// === Message 표준 메서드 (다형성 리소스) ===
@post("/{parent=chatRooms/*}/messages")
CreateMessage(req: CreateMessageRequest): Message;
@get("/{id=chatRooms/*/messages/*}")
GetMessage(req: GetMessageRequest): Message;
@get("/{parent=chatRooms/*}/messages")
ListMessages(req: ListMessagesRequest): ListMessagesResponse;
@delete("/{id=chatRooms/*/messages/*}")
DeleteMessage(req: DeleteMessageRequest): void;
}
interface ChatRoom {
id: string; // "chatRooms/room-1"
name: string;
memberCount: number;
}
interface CreateMessageRequest {
parent: string; // "chatRooms/room-1"
resource: Message;
}
// === 다형성 리소스: type으로 서브타입 결정 ===
interface Message {
id: string; // "chatRooms/room-1/messages/msg-42"
sender: string; // "users/alice"
type: 'text' | 'image' | 'audio' | 'video'; // 불변 문자열
content: string | Media; // type에 따라 다른 형태
createdAt: DateTime;
}
interface Media {
uri: string; // "https://storage.example.com/photo-123.jpg"
contentType: string; // "image/jpeg", "video/mp4", "audio/ogg"
sizeBytes: number; // 파일 크기
}
실제 API 호출 예시
// === 텍스트 메시지 생성 ===
POST /chatRooms/room-1/messages
{
sender: "users/alice",
type: "text",
content: "안녕하세요! 오늘 회의 시간이 언제인가요?"
}
→ 201 Created
{
id: "chatRooms/room-1/messages/msg-42",
sender: "users/alice",
type: "text",
content: "안녕하세요! 오늘 회의 시간이 언제인가요?",
createdAt: "2024-03-15T09:00:00Z"
}
// === 이미지 메시지 생성 ===
POST /chatRooms/room-1/messages
{
sender: "users/bob",
type: "image",
content: {
uri: "https://storage.example.com/meeting-schedule.png",
contentType: "image/png",
sizeBytes: 245760
}
}
→ 201 Created { id: "msg-43", type: "image", ... }
// === 모든 타입의 메시지를 한 번에 나열 ===
GET /chatRooms/room-1/messages
→ 200 OK
{
messages: [
{ id: "msg-42", type: "text", content: "안녕하세요!...", ... },
{ id: "msg-43", type: "image", content: { uri: "...", ... }, ... }
]
}
// === 특정 타입만 필터링 (22장 필터링 패턴) ===
GET /chatRooms/room-1/messages?filter=type='image'
→ 이미지 메시지만 반환
16.4 트레이드오프
한 줄 요약
다형성은 리소스를 특정 표현에 묶어놓게 된다. 비슷해 보이는 리소스도 근본적 차이가 존재할 수 있다.
- 리소스 결합: 다형성으로 묶인 리소스는 나중에 분리하기 어려울 수 있음
- 경험적 방법: 각 리소스가 제네릭 리소스의 특수한 타입인지 확인 (PhotoMessage는 Message의 특수 타입인가? ✅)
16.4.1 분리가 어려운 이유
처음: Message로 text, image, video를 통합 관리
→ 시간이 지남...
→ video 메시지에만 "트랜스코딩 상태", "스트리밍 URL" 필드가 필요
→ text 메시지에만 "서식(bold, italic)", "링크 미리보기" 필드가 필요
→ Message 인터페이스가 비대해짐
# 분리하려면?
# 기존: ListMessages → Message[] (모든 타입)
# 변경: ListTextMessages → TextMessage[], ListVideoMessages → VideoMessage[]
# → 기존 클라이언트 코드가 전부 깨짐! (breaking change)
16.4.2 새로운 타입 추가 시 주의사항
# 기존 타입: text, image, audio, video
# 새 타입 추가: sticker
# 서버 측: type: 'sticker' 추가 + content 구조 정의 → 간단!
# 클라이언트 측: "알 수 없는 타입"을 처리할 수 있어야 함
// 좋은 클라이언트 코드
switch (message.type) {
case 'text': renderText(message); break;
case 'image': renderImage(message); break;
default: renderGeneric(message); // ← 미지원 타입 처리!
}
// 나쁜 클라이언트 코드
if (message.type === 'text') { ... }
else if (message.type === 'image') { ... }
// else → 아무것도 안 함 → sticker 메시지가 안 보임!
16.5 연습문제 풀이
Q1: 결제 시스템에서 CreditCard, BankTransfer, MobilePay를 다형성 리소스로 관리할 때 type 필드와 데이터 구조를 설계하라.
A: ```typescript interface PaymentMethod { id: string; type: 'credit_card' | 'bank_transfer' | 'mobile_pay'; displayName: string; // "신한카드 ***1234" details: CreditCardDetails | BankTransferDetails | MobilePayDetails; }
interface CreditCardDetails { last4: string; brand: string; expMonth: number; expYear: number; } interface BankTransferDetails { bankName: string; accountNumber: string; } interface MobilePayDetails { provider: string; phoneNumber: string; } ```
Q2: 폴더와 파일을 하나의 다형성 리소스(Item)로 관리할 수 있는가?
A: 부적합하다. 폴더는 다른 항목을 포함하는 컨테이너이고 파일은 데이터를 저장하는 단위이다. 폴더에는 "하위 항목 추가/제거" 같은 메서드가 필요하지만 파일에는 불필요하다. 동작이 근본적으로 다르므로 별도 리소스가 적합.
Q3: 다형성 메서드(DeleteResource)가 안전한 경우가 있는가?
A: 모든 리소스 타입이 정확히 동일한 방식으로 삭제되고, 미래에도 분화되지 않을 것이 확실한 경우에만. 하지만 이런 확신은 거의 불가능하므로 실무에서는 항상 리소스별 삭제 메서드를 분리하는 것이 안전하다.
부록 A: 용어 사전
| 용어 | 영문 | 의미 |
|---|---|---|
| 다형성 | Polymorphism | 하나의 공통 인터페이스를 여러 타입에 적용하는 개념 |
| 다형성 리소스 | Polymorphic Resource | type 필드로 세부 타입을 구분하는 제네릭 리소스 |
| 다형성 메서드 | Polymorphic Method | 여러 리소스 타입에서 동작하는 제네릭 메서드 (비권장) |
| 슈퍼셋 | Superset | 모든 서브타입의 필드를 포함하는 통합 인터페이스 |
| 불변 타입 | Immutable Type | 생성 후 변경할 수 없는 type 필드 |
부록 B: 핵심 비교표
| 항목 | 독립 리소스 (타입별) | 다형성 리소스 |
|---|---|---|
| 메서드 수 | 타입 수 × 메서드 수 | 1세트 |
| 통합 나열 | 불가 (각각 호출) | ListResource 하나 |
| 타입별 동작 | 독립적 제어 가능 | 공통 동작만 |
| 확장성 | 새 타입 = 새 메서드 세트 | 새 타입 = type 값 추가 |
| 복잡도 | 타입 수에 비례 | 일정 |
부록 C: 추천 참고 자료 & 링크
| 자료 | 설명 |
|---|---|
| 5장 데이터 타입과 기본값 | 열거형 대신 문자열을 사용하는 이유 |
| 22장 필터링 | type 필드로 특정 타입만 나열하기 |
| 25장 논리 삭제 | 다형성 메서드가 위험한 이유의 예시 |
클릭하거나 Space를 눌러 뒤집기